● Zion Boggan
repos/Docket/app/members/[id]/page.tsx
zionboggan.com ↗
390 lines · tsx
History for this file →
1
import type { Metadata } from "next";
2
import Link from "next/link";
3
import { notFound } from "next/navigation";
4
import { Card, SectionHeader } from "@/components/ui";
5
import { DataTable, type Column, type Row } from "@/components/data-table";
6
import { SourceLink } from "@/components/source-link";
7
import { PartyTag } from "@/components/party-tag";
8
import { PositionTag } from "@/components/position-tag";
9
import { MemberAvatar } from "@/components/member-avatar";
10
import { StatusPill, classifyStatus, type StatusVariant } from "@/components/status-pill";
11
import {
12
  getMember,
13
  type MemberVote,
14
  type MemberAmendment,
15
} from "@/lib/feed";
16
import { MemberVoteYear } from "@/components/member-vote-year";
17
import { formatDate } from "@/lib/format";
18
import { CiteThis } from "@/components/cite-this";
19
 
20
export const revalidate = 300;
21
 
22
 
23
const STATUS_SHORT: Record<StatusVariant, string> = {
24
  passed: "Passed",
25
  failed: "Failed",
26
  held: "In committee",
27
  pending: "Introduced",
28
  neutral: "Introduced",
29
};
30
 
31
export function generateMetadata({
32
  params,
33
}: {
34
  params: { id: string };
35
}): Metadata {
36
  const member = getMember(params.id);
37
  if (!member) return { title: "Member not found" };
38
  return {
39
    title: `${member.name} (${member.partyCode}-${member.state})`,
40
    description: `Recorded votes, sponsored bills, and amendments for ${member.name}.`,
41
  };
42
}
43
 
44
function formatDistrict(chamber: string, district: number | null): string {
45
  if (chamber === "senate") return "";
46
  if (district === null || district <= 0) return "At-Large";
47
  return `District ${district}`;
48
}
49
 
50
export default function MemberDetailPage({
51
  params,
52
}: {
53
  params: { id: string };
54
}) {
55
  const member = getMember(params.id);
56
  if (!member) notFound();
57
 
58
  return (
59
    <div
60
      className="mx-auto w-full max-w-[860px] overflow-x-hidden px-6 sm:px-8"
61
      style={{
62
        paddingTop: "clamp(32px, 6vw, 56px)",
63
        paddingBottom: "clamp(48px, 8vw, 80px)",
64
      }}
65
    >
66
      <Link
67
        href="/members"
68
        className="focus-ring tap no-underline hover:underline"
69
        style={{
70
          display: "inline-flex",
71
          alignItems: "center",
72
          gap: "8px",
73
          fontFamily: "var(--font-mono), ui-monospace, monospace",
74
          fontSize: "13px",
75
          letterSpacing: "0.08em",
76
          color: "var(--dk-signal)",
77
          marginBottom: "32px",
78
        }}
79
      >
80
        ← Representatives
81
      </Link>
82
 
83
      {
84
}
85
      <header
86
        style={{
87
          borderBottom: "1px solid var(--dk-line)",
88
          paddingBottom: "clamp(28px, 5vw, 48px)",
89
          marginBottom: "clamp(28px, 5vw, 48px)",
90
        }}
91
      >
92
        {}
93
        <p
94
          className="label"
95
          style={{
96
            color: "var(--dk-signal)",
97
            marginBottom: "20px",
98
            display: "flex",
99
            alignItems: "center",
100
            gap: "8px",
101
            fontSize: "12px",
102
            letterSpacing: "0.14em",
103
          }}
104
        >
105
          <span
106
            aria-hidden="true"
107
            style={{
108
              width: "5px",
109
              height: "5px",
110
              borderRadius: "50%",
111
              background: "var(--dk-signal)",
112
              display: "inline-block",
113
              flexShrink: 0,
114
            }}
115
          />
116
          {member.chamber === "senate" ? "U.S. Senate" : "U.S. House"} · {member.state}
117
          {member.chamber !== "senate" && formatDistrict(member.chamber, member.district)
118
            ? ` · ${formatDistrict(member.chamber, member.district)}`
119
            : ""}
120
        </p>
121
        <div className="flex flex-col gap-5 sm:flex-row sm:items-start">
122
          <MemberAvatar
123
            name={member.name}
124
            photoUrl={member.photoUrl}
125
            size="lg"
126
            className="shrink-0"
127
          />
128
          <div className="min-w-0 flex-1">
129
            <h1
130
              className="font-display text-paper"
131
              style={{
132
                fontSize: "clamp(2.2rem, 7vw, 3.75rem)",
133
                fontWeight: 900,
134
                lineHeight: 1.0,
135
                letterSpacing: "-0.01em",
136
                wordSpacing: "0.06em",
137
                textTransform: "uppercase",
138
                marginBottom: "20px",
139
              }}
140
            >
141
              {member.name}
142
            </h1>
143
            <div className="flex flex-wrap items-center gap-3">
144
              <PartyTag party={member.partyCode} />
145
              <span className="font-mono text-[13px] text-ash">
146
                {member.party || "Party not on record"}
147
              </span>
148
              <span className="label text-ash-2" aria-hidden="true">·</span>
149
              <span className="font-mono text-[13px] text-ash-2">
150
                {member.voteCount} recorded position{member.voteCount === 1 ? "" : "s"} on file
151
              </span>
152
            </div>
153
          </div>
154
        </div>
155
        {member.photoUrl ? (
156
          <p className="label mt-3 text-ash-2">
157
            Headshot from the official congressional image record. Official biography below.
158
          </p>
159
        ) : (
160
          <p className="label mt-3 text-ash-2">
161
            No official headshot on file for this member.
162
          </p>
163
        )}
164
      </header>
165
 
166
      {}
167
      <div className="grid gap-4 sm:grid-cols-3">
168
        <Card className="p-5">
169
          <p className="label mb-2">Party</p>
170
          <PartyTag party={member.partyCode} />
171
          <p className="label mt-2 text-ash-2">{member.party || "-"}</p>
172
        </Card>
173
 
174
        <Card className="p-5">
175
          <p className="label mb-2">State / district</p>
176
          <p className="font-mono text-[13px] text-paper">{member.state}</p>
177
          <p className="label mt-2 text-ash-2">
178
            {member.chamber === "senate"
179
              ? "Statewide"
180
              : formatDistrict(member.chamber, member.district) || "Statewide"}
181
          </p>
182
        </Card>
183
 
184
        <Card className="p-5">
185
          <p className="label mb-2">Official record</p>
186
          <SourceLink url={member.sourceUrl} label="Bioguide" />
187
          <p className="label mt-2 text-ash-2">{member.bioguide}</p>
188
        </Card>
189
      </div>
190
 
191
      <div className="mt-10 grid gap-8 lg:grid-cols-[1.3fr_1fr]">
192
        {
193
}
194
        <div className="min-w-0">
195
          <SectionHeader
196
            eyebrow={`Most recent · ${Math.min(member.votes.length, 12)} of ${member.voteCount.toLocaleString()}`}
197
            title="How they voted"
198
          />
199
          {member.votes.length === 0 ? (
200
            <Card className="p-5">
201
              <p className="font-mono text-[13px] text-ash-2">
202
                No recorded vote positions are linked to this member.
203
              </p>
204
            </Card>
205
          ) : (
206
            <RecentVotes votes={member.votes.slice(0, 12)} />
207
          )}
208
        </div>
209
 
210
        {}
211
        <div className="min-w-0">
212
          <SectionHeader
213
            eyebrow={`Sponsored · ${member.sponsorships.length}`}
214
            title="Bills introduced"
215
          />
216
          {member.sponsorships.length === 0 ? (
217
            <Card className="p-5">
218
              <p className="font-mono text-[13px] text-ash-2">
219
                No sponsored bills are linked to this member in the current record.
220
              </p>
221
            </Card>
222
          ) : (
223
            <Card className="divide-y divide-[color:var(--dk-line)]">
224
              {member.sponsorships.slice(0, 12).map((b) => (
225
                <div key={b.billId} className="p-4">
226
                  <Link
227
                    href={b.billHref}
228
                    className="focus-ring font-mono text-[13px] text-paper underline underline-offset-4 hover:text-paper"
229
                  >
230
                    {b.number}
231
                  </Link>
232
                  <p className="mt-1 font-mono text-[12px] text-ash">
233
                    {b.title}
234
                  </p>
235
                  <div className="mt-2 flex items-center gap-2">
236
                    <StatusPill variant={classifyStatus(b.status)}>
237
                      {STATUS_SHORT[classifyStatus(b.status)]}
238
                    </StatusPill>
239
                    <span className="label text-ash-2">{formatDate(b.updatedAt)}</span>
240
                  </div>
241
                </div>
242
              ))}
243
            </Card>
244
          )}
245
        </div>
246
      </div>
247
 
248
      {}
249
      <div className="mt-10">
250
        <SectionHeader
251
          eyebrow={`By year · ${member.votesByYear.length} year${member.votesByYear.length === 1 ? "" : "s"}`}
252
          title="Vote record, year by year"
253
          description="Every recorded roll-call position this member has taken, grouped by year. Open a year to read each vote and how they voted. Newest year first."
254
        />
255
        {member.votesByYear.length === 0 ? (
256
          <Card className="p-5">
257
            <p className="font-mono text-[13px] text-ash-2">
258
              No recorded vote positions are linked to this member.
259
            </p>
260
          </Card>
261
        ) : (
262
          <>
263
            {}
264
            <ul className="mb-5 flex flex-wrap gap-2">
265
              {member.votesByYear.map((yg) => (
266
                <li
267
                  key={yg.year ?? "undated"}
268
                  className="label inline-flex min-h-[44px] items-center gap-2 rounded-[3px] border border-line px-2.5 text-ash"
269
                >
270
                  <span className="text-paper">{yg.year ?? "Undated"}</span>
271
                  <span className="text-ash-2">{yg.count}</span>
272
                </li>
273
              ))}
274
            </ul>
275
 
276
            {
277
}
278
            <div className="space-y-3">
279
              {member.votesByYear.map((yg, i) => (
280
                <MemberVoteYear
281
                  key={yg.year ?? "undated"}
282
                  bioguide={member.bioguide}
283
                  year={yg.year}
284
                  count={yg.count}
285
                  defaultOpen={i === 0}
286
                />
287
              ))}
288
            </div>
289
          </>
290
        )}
291
      </div>
292
 
293
      {}
294
      {member.amendments.length > 0 ? (
295
        <div className="mt-10">
296
          <SectionHeader
297
            eyebrow={`Amendments · ${member.amendments.length}`}
298
            title="Amendments offered"
299
          />
300
          <MemberAmendmentsTable amendments={member.amendments} />
301
        </div>
302
      ) : null}
303
 
304
      <div className="mt-12 border-t border-line pt-6">
305
        <p className="label text-ash-2">
306
          Vote positions and sponsorships from clerk.house.gov, senate.gov, and congress.gov. No comment or reaction surface appears here, by policy.
307
        </p>
308
      </div>
309
 
310
      {}
311
      <div className="mt-8">
312
        <p className="label mb-4 text-ash">Cite this record</p>
313
        <CiteThis
314
          kind="member"
315
          title={`${member.name} (${member.partyCode}-${member.state}) - Congressional Record`}
316
          permalink={`https://usdocket.org${member.href}`}
317
          sourceUrl={member.sourceUrl}
318
        />
319
      </div>
320
    </div>
321
  );
322
}
323
 
324
 
325
function RecentVotes({ votes }: { votes: MemberVote[] }) {
326
  return (
327
    <Card className="divide-y divide-[color:var(--dk-line)]">
328
      {votes.map((v) => (
329
        <div key={v.voteId} className="flex items-start justify-between gap-3 p-4">
330
          <div className="min-w-0 flex-1">
331
            <Link
332
              href={v.voteHref}
333
              className="focus-ring block break-words font-mono text-[13px] text-paper underline underline-offset-4 hover:text-paper"
334
            >
335
              {v.question || v.result}
336
            </Link>
337
            <p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
338
              <span className="label text-ash-2">{v.chamber}</span>
339
              <span className="label text-ash-2" aria-hidden="true">·</span>
340
              <span className="label tabular-nums text-ash-2">{formatDate(v.date)}</span>
341
              <span className="label text-ash-2" aria-hidden="true">·</span>
342
              <span className="label text-ash-2">{v.result}</span>
343
            </p>
344
          </div>
345
          <PositionTag position={v.position} className="mt-0.5 shrink-0" />
346
        </div>
347
      ))}
348
    </Card>
349
  );
350
}
351
 
352
function MemberAmendmentsTable({
353
  amendments,
354
}: {
355
  amendments: MemberAmendment[];
356
}) {
357
  const columns: Column[] = [
358
    { key: "amdt", header: "Amendment", cardTitle: true },
359
    { key: "bill", header: "Bill" },
360
    { key: "status", header: "Status" },
361
    { key: "source", header: "Source", align: "right" },
362
  ];
363
  const rows: Row[] = amendments.map((a) => ({
364
    key: a.amendmentId,
365
    cells: {
366
      amdt: <span className="text-paper">{a.type} {a.number}</span>,
367
      bill:
368
        a.billHref ? (
369
          <Link
370
            href={a.billHref}
371
            className="focus-ring rounded-sm text-ash underline underline-offset-4 hover:text-paper"
372
          >
373
            Bill {a.billId}
374
          </Link>
375
        ) : (
376
          <span className="text-ash-2">-</span>
377
        ),
378
      status: <span className="uppercase text-ash-2">{a.status || "-"}</span>,
379
      source: <SourceLink url={a.sourceUrl} />,
380
    },
381
  }));
382
  return (
383
    <DataTable
384
      caption="Amendments offered by this member: amendment, bill, status, source."
385
      cardLabel="Amendments"
386
      columns={columns}
387
      rows={rows}
388
    />
389
  );
390
}